230827 Balancer Incident

Abstract

于 2023 年 8 月 27 日在 Balancer 上发生的安全事件,由于 「Reset Rate to 0 Supply」和 「舍入错误」的漏洞,攻击者在 Balancer 上盗取了价值逾 1M 美元的虚拟货币。

Background on Balancer

Various Pools

Linear Pools

scalingFactor 计算方法

对于 Linear Pools 来说,其 main Token 和 BPT token 对应的缩放因子都是定值。

Composable Stable Pools

Pasted image 20231024145426.png

Boosted Pools

scalingFactor 计算方法

对于 Boosted Pools 来说,实际缩放因子 = 原始缩放因子 × Token Rate。Token Rate 的计算方法在下文中会提及。

Math

Token Rate

Token Rate 的计算方式如下所示,其中 Main Balance 和 Wrapped Balance 以及 BPT Balance 都需要先经过 标准化(nominal):

tokenRate=nominalMainBalance+nominalWrappedBalance_INITIAL_BPT_SUPPLYnominalBptBalance

以下是 Balancer Aave Boosted Pool (USDC) (bb-a-USDC) getRate 函数的源码

function getRate() external view override returns (uint256) {
	bytes32 poolId = getPoolId();
	(, uint256[] memory balances, ) = getVault().getPoolTokens(poolId);
	_upscaleArray(balances, _scalingFactors());

	(uint256 lowerTarget, uint256 upperTarget) = getTargets();
	LinearMath.Params memory params = LinearMath.Params({
		fee: getSwapFeePercentage(),
		lowerTarget: lowerTarget,
		upperTarget: upperTarget
	});

	uint256 totalBalance = LinearMath._calcInvariant(
		LinearMath._toNominal(balances[_mainIndex], params),
		balances[_wrappedIndex]
	);

	// Note that we're dividing by the virtual supply, which may be zero (causing this call to revert). However, the
	// only way for that to happen would be for all LPs to exit the Pool, and nothing prevents new LPs from
	// joining it later on.
	return totalBalance.divUp(_getApproximateVirtualSupply(balances[_bptIndex]));
}

toNominal

Nominal 的计算遵循如下公式:

nominalBalance={real,lowerrealupperreal(lowerreal)×fee,reallowerreal(realupper)×fee,upperreal

以下是 Balancer Aave Boosted Pool (USDC) (bb-a-USDC) _toNominal 函数的源码:

function _toNominal(uint256 real, Params memory params) internal pure returns (uint256) {
	// Fees are always rounded down: either direction would work but we need to be consistent, and rounding down
	// uses less gas.

	if (real < params.lowerTarget) {
		uint256 fees = (params.lowerTarget - real).mulDown(params.fee);
		return real.sub(fees);
	} else if (real <= params.upperTarget) {
		return real;
	} else {
		uint256 fees = (real - params.upperTarget).mulDown(params.fee);
		return real.sub(fees);
	}
}

BatchSwap

BatchSwap 是 Balancer V2 提供的一个支持“批量交换”的功能接口,支持通过 GIVEN_IN 估算 GIVEN_OUT,也支持相反操作,最终一并结算所有交换执行完毕的交换结果。

调用不同的 onSwap

以 StablePhantom Pool 为例,当用户可以在其中的某个线性池(如 bb-a-USDC Pool)中进行涉及 Main Balance、Wrapped Balance 和 BPT Balance 的兑换,也可以跨线性池使用 BPT Balance 进行交易(如使用 bb-a-USDC 兑换 bb-a-DAI)。前者的交易调用的是线性池自身的 onSwap 函数,而后者的交易调用的是 StablePhantom Pool 的 onSwap 函数。

Rate Cache 更新过程

由于涉及跨线性池的兑换交易,在进行兑换数额换算的过程中,需要获取对应线性池 BPT Balance 的 Token Rate,在 StablePhantom Pool 中,它会先获取缓存好的 Rate 进行换算,并检查是否要更新 Rate。
以下是 StablePhantom Pool onSwap 函数的源代码,可以看出 Rate 的更新和使用次序:

function onSwap(
	SwapRequest memory swapRequest,
	uint256[] memory balances,
	uint256 indexIn,
	uint256 indexOut
) public virtual override onlyVault(swapRequest.poolId) returns (uint256) {
	_validateIndexes(indexIn, indexOut, _getTotalTokens());
	// 先通过 _scalingFactors() 获取缓存好的 scalingFactors
	uint256[] memory scalingFactors = _scalingFactors();

	return
		swapRequest.kind == IVault.SwapKind.GIVEN_IN
		// 再通过 _swapGivenIn / _swapGivenOut 更新 scalingFactors 的缓存
			? _swapGivenIn(swapRequest, balances, indexIn, indexOut, scalingFactors)
			: _swapGivenOut(swapRequest, balances, indexIn, indexOut, scalingFactors);
}

以下是执行 BatchSwap 时,涉及 onSwap 以及 Rate 更新的调用示意图:

graph TD
	F1(Balancer: Vault.batchSwap)
	F2(Balancer: Vault._swapWithPools)
	F3(Balancer: Vault._swapWithPool)
	F4(Balancer: _processGeneralPoolSwapRequest)
	F5(bb-a-USDC: onSwap)
	F6(bb-a-USDC: _onSwapGivenIn)
	F7(bb-a-USDC: _downscaleDown)
	F8(bb-a-USDC: FixedPoint.divDown)
	F9(StablePhantomPool: onSwap)
	F10(StablePhantomPool: _scalingFactors)
	F11(StablePhantomPool: _swapGivenIn)
	F12(StablePhantomPool: _onSwapGivenIn)
	F13(StablePhantomPool: _cacheTokenRatesIfNecessary)
	F14(StablePhantomPool: _cacheTokenRateIfNecessary)
	F15(StablePhantomPool: _updateTokenRateCache)
	F16(StablePhantomPool: getTokenRate)
	F17(StablePhantomPool: getRate)
	V1([Cache Rate])
	F1-->F2-->F3-->F4-->F5-->F6-->F7-->F8
	F9-->F10-->F16-->F17-- Read -->V1
	F4-->F9-->F11-->F12-->F13-->F14-->F15-- "Write bb-a-USDC.getRate()" -->V1

Vulnerabilities

Round Down Error 舍入错误

bb-a-USDC Pool 在通过 onSwap 函数计算兑换金额时,会调用 _downscaleDown 函数,此函数本身是一个向下取整的除法,存在四舍五入的精度误差。

在 bb-a-USDC Pool 换算 BPT Balance 和 Main BPT Balance 时,会调用 _downscaleDown 函数(最终调用的是 FixedPoint.divDown 函数),此函数使用向下取整,当 amountOut 小于 1,000,000,000,000 时,返回值将始终向下舍入为零。

Reset Rate on 0 Supply

开发者在写 bb-a-USDC Pool 的时候,做了取巧的处理,为了方便合约的初始化,通过 BPT Balance 的值是否为 0 的方式判断合约是否是在初始化状态,并在此状态下设置兑换比例为 1:1。

具体的实现为:在 bb-a-USDC Pool 的 _calcBptOutPerMainIn 函数中,当 BPT Balance 的余额为 0 时,Main Balance(USDC) 和 BPT Balance(bb-a-USDC) 进行等额兑换,具体可见下方代码:

function _calcBptOutPerMainIn(
	uint256 mainIn,
	uint256 mainBalance,
	uint256 wrappedBalance,
	uint256 bptSupply,
	Params memory params
) internal pure returns (uint256) {
	// Amount out, so we round down overall.

	if (bptSupply == 0) { // Reset Rate on 0 Supply 漏洞的代码段
		// BPT typically grows in the same ratio the invariant does. The first time liquidity is added however, the
		// BPT supply is initialized to equal the invariant (which in this case is just the nominal main balance as
		// there is no wrapped balance).
		return _toNominal(mainIn, params);
	}

	uint256 previousNominalMain = _toNominal(mainBalance, params);
	uint256 afterNominalMain = _toNominal(mainBalance.add(mainIn), params);
	uint256 deltaNominalMain = afterNominalMain.sub(previousNominalMain);
	uint256 invariant = _calcInvariant(previousNominalMain, wrappedBalance);
	return Math.divDown(Math.mul(bptSupply, deltaNominalMain), invariant);
}

而由于 bb-a-USDC Pool 缺少对 BPT 余额的约束,使得攻击者能够通过正常的兑换交易将 BPT 消耗到 0,以利用 1:1 兑换的特性,在借用 bb-a-USDC(a BPT Token) 获取超额收益后,再通过等额兑换归还 bb-a-USDC。

Attack Process

Main Process

  1. 通过 Aave 的 Flashloan 借款 300,000 USDC
  2. 用 1.067753 USDC 交换 bb-a-USDC 池中的 0.970495 aUSDCPasted image 20231017161954.png
  3. 在 bb-a-USDC 和 bb-a-USD 池中执行批量交换(batchSwap),收获 15,628 bb-a-USDC、139,431 bb-a-DAI 和 248,868 bb-a-USDT 与 42,203 USDC
  4. 使用 LP tokens 交换对应的基础稳定币,比如 139,431 bb-a-DAI -> 141,127 DAI
  5. 向 Flashloan 还款,最终结余:114,324 DAI、253,461 USDT 和 0.970495 aUSDC

关键攻击过程

在调用 batchSwap 过程中发生的主要交易如下图所示:
Pasted image 20231018142929.png

(主要异常并产生收益的交易发生在 3.4、3.5 和 3.7)

交易 3.2:舍入误差导致 Rate 的大幅膨胀

由于 bb-a-USDC 在计算兑换的 Main Balance 数额时存在向下舍入的数额误差(_calcMainOutPerBptIn 函数),导致攻击者可以在保持 Pool 的 Main Balance 数量不变的情况下,变动 BPT Balance 的数量。具体计算过程如下图:

Pasted image 20231019165924.png

在得到 amountOut = 784,399,492,780 后,onSwap 函数通过 _downscaleDown(amountOut, scalingFactors[indexOut] 语句计算最终入池的 USDC 数量,如下公式所示:

784,399,492,7801,000,000,000,000=0

因为在交易 3.2 中,bb-a-USDC 被提取出了 775,114,420,171,导致此时的 BPT Balance(bb-a-USDC) 实际供应量只有 20,000,000,000 而非 795,114,420,171,但由于舍入偏差,Main Balance(USDC) 并没有任何进账,这也 Rate 由 1.01 膨胀至 40.24。

Rate=nominalMainBalance_INITIAL_BPT_SUPPLYnominalBptBalance=realMainBalancefeesVirtual Supply
情况 realMainBalance nominalMainBalance Virtal Supply Rate
3.2 交易前 579,884,024,000,000,000,000 804,800,000,000 795,114,420,171 1.012
3.2 交易后(非四舍五入) 579,884,023,215,600,507,220 20,400,507,220 20,000,000,000 1.02
3.2 交易后(四舍五入) 579,884,024,000,000,000,000 804,800,000,000 20,000,000,000 40.240

根据上表可以发现,因为 divDown 造成的四舍五入,导致 Real 存在 1e12 范围内的偏差,而导致 nominalMainBalance 发生了几个数量级的变化(20,400,507,220 -> 804,800,000,000),从而 Rate 也膨胀了四十倍。

交易 3.3:更新被操纵的 Rate

在交易 3.3 时,StablePhantomPool 通过 _swapGivenIn 更新了 bb-a-USDC 对应的 Rate 值,具体计算过程如下图所示(可对照 toNominal 函数):
Pasted image 20231020112639.png

交易 3.4/3.5:超额兑换 bb-a-DAIbb-a-USDT

交易 3.6:将 bb-a-USDC 操纵为 0

在交易 3.6 中,由于 bb-a-USDC Pool 缺少对 BPT 数量的检测(控制其始终大于 0),导致攻击者可以轻松地将剩余的所有 BPT 租借出来(通过下图也可发现,USDCbb-a-USDC 的兑换比率从原来的接近 1:1 膨胀到了 40:1)。
Pasted image 20231019170923.png

其实此时攻击者是否利用舍入漏洞“白嫖” bb-a-USDC 也并不重要,因为其数额实在太小。

交易 3.7:利用漏洞进行等额偿还

交易 3.7 是 攻击者套利的重要一环,因为按照 Rate 的计算方式,此时偿还 bb-a-USDC,理应支付 40 倍的 USDC,那么攻击者是没有套利空间的。换句话说,即使通过 40 倍的溢价兑换了其他代币,但仍需在 40 倍溢价的条件下偿还租借的 bb-a-USDC

但是 bb-a-USDC Pool 中存在一个致命的漏洞,就是当 bb-a-USDC = 0 时,bb-a-USDCUSDC 的兑换比率为 1:1!(这一点在「Reset Rate on 0 supply」章节有详细代码)

那么攻击者就可以轻松偿还租借的 bb-a-USDC,并独享其中的 39 倍的差额利润。

为什么 Balancer 认为 Reset Rate on 0 supply 是攻击能够发生的主要原因?

Balancer 官方原文:就其本身而言,提高代币的利率是可以的(这就是为什么我们在设计过程中并不担心它)。只要利率保持在高位,就没有办法在不遭受巨大损失的情况下还清债务。
⚠️但是事实证明,由于精度造成的舍入误差,Balancer 在计算 Rate 的时候发生了几十倍的偏差,而正是这样的偏差使得攻击者能够轻松制造出高额溢价,是其获利的一个重要原因。
😃当然我也认同 Balancer 官方的看法,因为根据其计算公式,如果不存在「Reset Rate on 0 supply」这个漏洞,攻击者的套利空间是很小甚至几乎没有的,正是因为攻击者可以在 bb-a-USDC 价格高度膨胀后利用漏洞进行 1:1 的偿还,打破数学上的约束,才形成了巨额的套利空间。

References